package com.vvv.shortlink.project.service.impl;


import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.vvv.shortlink.common.convention.excepton.ClientException;
import com.vvv.shortlink.common.convention.excepton.ServiceException;
import com.vvv.shortlink.common.enums.VailDateTypeEnum;
import com.vvv.shortlink.common.toolkit.HashUtil;
import com.vvv.shortlink.common.toolkit.LinkUtil;
import com.vvv.shortlink.project.constant.RocketMqConstant;
import com.vvv.shortlink.project.dao.entity.LinkDO;
import com.vvv.shortlink.project.dao.entity.LinkGotoDO;
import com.vvv.shortlink.project.dao.mapper.LinkGotoMapper;
import com.vvv.shortlink.project.dao.mapper.LinkMapper;
import com.vvv.shortlink.project.dto.biz.LinkStatsRecordDTO;
import com.vvv.shortlink.project.dto.req.LinkBatchCreateReqDTO;
import com.vvv.shortlink.project.dto.req.LinkCreateReqDTO;
import com.vvv.shortlink.project.dto.req.LinkPageReqDTO;
import com.vvv.shortlink.project.dto.req.LinkUpdateReqDTO;
import com.vvv.shortlink.project.dto.resp.*;
import com.vvv.shortlink.project.handler.LinkServiceAsyncHandler;
import com.vvv.shortlink.project.mq.producer.LinkStatsProducer;
import com.vvv.shortlink.project.properties.GotoDomainBlackListProperties;
import com.vvv.shortlink.project.service.LinkService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.TimeUnit;

import static com.vvv.shortlink.project.constant.RedisKeyConstant.*;
import static com.vvv.shortlink.project.constant.RedisValueConstant.SHORT_LINK_GOTO_IS_NULL_VALUE;

/**
 * @author Dell
 * @description 针对表【t_link】的数据库操作Service实现
 * @createDate 2024-02-02 10:21:57
 */
@Service
@Slf4j
@RefreshScope
@RequiredArgsConstructor
@EnableConfigurationProperties(value = {GotoDomainBlackListProperties.class})
public class LinkServiceImpl extends ServiceImpl<LinkMapper, LinkDO>
        implements LinkService {


    private final LinkMapper linkMapper;
    private final LinkGotoMapper linkGotoMapper;
    private final StringRedisTemplate stringRedisTemplate;
    private final RedissonClient redissonClient;
    private final GotoDomainBlackListProperties gotoDomainBlackListProperties;
    private final LinkStatsProducer linkStatsProducer;

    private final LinkServiceAsyncHandler linkServiceAsyncHandler;

    @Resource
    private final RBloomFilter<String> shortLinkCreateCachePenetrationBloomFilter;

    @Value("${short-link.domain.default}")
    private String createLinkDefaultDomain;

    @Override
    @Transactional
    public LinkCreateRespDTO createShortLink(LinkCreateReqDTO requestParam) {
        String domain = createLinkDefaultDomain;
        String originUrl = requestParam.getOriginUrl();
        String gid = requestParam.getGid();
        Long userId = requestParam.getUserId();
        if (StringUtils.isAnyEmpty(originUrl, gid) || userId == null) {
            throw new ClientException("参数不能为空");
        }
        // 判断目标url是否在黑白名单内
        verificationBlackWhitelist(originUrl);
        // 生成短链接uri
        String shortLinkSuffix = this.generateShortLinkSuffix(originUrl, domain);
        String fullShortUrl = domain + "/" + shortLinkSuffix;

        LinkDO linkDO = BeanUtil.toBean(requestParam, LinkDO.class);
        linkDO.setShortUri(shortLinkSuffix);
        linkDO.setDomain(domain);
        linkDO.setFullShortUrl(fullShortUrl);
        LinkGotoDO linkGotoDO = LinkGotoDO.builder()
                .gid(gid)
                .fullShortUrl(fullShortUrl)
                .userId(requestParam.getUserId())
                .build();
        try {
            baseMapper.insert(linkDO);
            // gid 和 完整短链接url 对应表
            linkGotoMapper.insert(linkGotoDO);
        } catch (DataIntegrityViolationException e) {
            // 首先判断是否存在布隆过滤器，如果不存在直接新增
            if (!shortLinkCreateCachePenetrationBloomFilter.contains(fullShortUrl)) {
                shortLinkCreateCachePenetrationBloomFilter.add(fullShortUrl);
            }
            throw new ServiceException(String.format("短链接：%s 生成重复", fullShortUrl));
        } catch (Exception e) {
            log.error("短链接uri：uri生成冲突，可能原因：布隆过滤器和数据库的fullShortUrl不一致\n" + e);
            throw new ServiceException("创建短链接失败，请稍后重试");
        }
        // 将完整短链接写入布隆过滤器
        shortLinkCreateCachePenetrationBloomFilter.add(fullShortUrl);
        // 缓存预热
        String shortLinkCacheKey = SHORT_LINK_GOTO_ORIGIN_KEY + fullShortUrl;
        stringRedisTemplate.opsForValue().set(shortLinkCacheKey, linkDO.getOriginUrl(),
                LinkUtil.getCacheShortLinkValidTime(linkDO.getValidDate()), TimeUnit.SECONDS);
        // 异步获取短链图标
        linkServiceAsyncHandler.fetchFaviconFuture(userId, gid, fullShortUrl, originUrl);
        return LinkCreateRespDTO.builder()
                .fullShortUrl("http://" + linkDO.getFullShortUrl())
                .originUrl(linkDO.getOriginUrl())
                .gid(requestParam.getGid())
                .build();
    }

    @Override
    public IPage<LinkPageRespDTO> pageShortLink(LinkPageReqDTO requestParam) {
        return baseMapper.pageLink(requestParam);
    }

    @Override
    public List<LinkGroupCountRespDTO> listShortLinkGroupCount(Long userId) {
        return linkMapper.listShortLinkGroupCount(userId);
    }

    /**
     * 更新短链接信息
     *
     * @param requestParam 入参
     */
    @Override
    public void updateShortLink(LinkUpdateReqDTO requestParam) {
        String fullShortUrl = requestParam.getFullShortUrl();
        String originUrl = requestParam.getOriginUrl();
        String gid = requestParam.getGid();
        String originGid = requestParam.getOriginGid();
        Long userId = requestParam.getUserId();
        String favicon = requestParam.getFavicon();
        Integer enableStatus = requestParam.getEnableStatus();
        Integer validDateType = requestParam.getValidDateType();
        Date validDate = DateUtil.parse(requestParam.getValidDate());
        String describe = requestParam.getDescribe();
        // 1. 参数检验
        if (userId == null) {
            throw new ClientException("用户标识不能为空");
        }
        if (StringUtils.isEmpty(originGid)) {
            throw new ClientException("原始分组标识不能为空");
        }
        if (StringUtils.isEmpty(fullShortUrl)) {
            throw new ClientException("原始完整短链接不能为空");
        }
        // 验证修改域名是否在黑白名单内
        verificationBlackWhitelist(originUrl);
        // 2. 根据这个三个参数去找到原始短链数据
        LambdaQueryWrapper<LinkDO> queryWrapper = Wrappers.lambdaQuery(LinkDO.class)
                .eq(LinkDO::getUserId, userId)
                .eq(LinkDO::getGid, originGid)
                .eq(LinkDO::getFullShortUrl, fullShortUrl);
        LinkDO oldLinkDO = baseMapper.selectOne(queryWrapper);
        if (oldLinkDO == null) {
            throw new ClientException("短链接记录不存在");
        }
        // 更新短链条件
        LambdaUpdateWrapper<LinkDO> updateWrapper = Wrappers.lambdaUpdate(LinkDO.class)
                .eq(LinkDO::getUserId, userId)
                .eq(LinkDO::getGid, originGid)
                .eq(LinkDO::getFullShortUrl, fullShortUrl)
                .set(Objects.equals(validDateType, VailDateTypeEnum.PERMANENT.getType()), LinkDO::getValidDate, null);
        LinkDO updateLinkDO = LinkDO.builder()
                .favicon(favicon)
                .originUrl(originUrl)
                .describe(describe)
                .validDateType(validDateType)
                .validDate(validDate)
                .enableStatus(enableStatus)
                .build();
        // 3. 不同gid情况
        if (!originGid.equals(gid)) {
            updateLinkDO.setGid(gid);
        }
        baseMapper.update(updateLinkDO, updateWrapper);
        // 删除缓存
        String shortLinkCacheKey = SHORT_LINK_GOTO_ORIGIN_KEY + fullShortUrl;
        stringRedisTemplate.delete(shortLinkCacheKey);
        // 如果有效期类型修改成永久有效或者修改成未来，那么删除缓存空值
        validDateType = oldLinkDO.getValidDateType();
        validDate = oldLinkDO.getValidDate();
        if (validDateType == 0 || validDate.after(new Date())) {
            String shortLinkIsNullKey = SHORT_LINK_GOTO_IS_NULL_KEY + fullShortUrl;
            stringRedisTemplate.delete(shortLinkIsNullKey);
        }
    }

    /**
     * 根据短链接uri跳转原始链接
     *
     * @param shortUri 短链接uri
     * @param request  Http 请求
     * @param response Http 响应
     */
    @Override
    public void restoreUrl(String shortUri, HttpServletRequest request, HttpServletResponse response) {
        // 1. 参数检验
        if (StringUtils.isEmpty(shortUri)) {
            throw new ClientException("短链接uri不能为空");
        }
        // 2. 构造出完整短链接full_short_url
        String serverName = request.getServerName();
        String fullShortUrl;
        // 端口
        int serverPort = request.getServerPort();
        if (serverPort == 80) {
            fullShortUrl = serverName + "/" + shortUri;
        } else {
            fullShortUrl = serverName + ":" + serverPort + "/" + shortUri;
        }
        // 3. 尝试从redis中获取短链对应的原始链接
        String shortLinkCacheKey = SHORT_LINK_GOTO_ORIGIN_KEY + fullShortUrl;
        String originUrl = stringRedisTemplate.opsForValue().get(shortLinkCacheKey);
        // 3.1 redis中有
        if (StringUtils.isNotEmpty(originUrl)) {
            // 监控短链接
            setCookieAndMonitorShortLink(fullShortUrl, null, request, response);
            this.redirect(response, originUrl);
            return;
        }
        // 3.2 redis中没有，说明可能缓存穿透(恶意)，可能缓存击穿
        // 4.1 缓存穿透-布隆过滤器
        boolean isExist = shortLinkCreateCachePenetrationBloomFilter.contains(fullShortUrl);
        if (!isExist) {
            /**
             * 不存在布隆过滤器说明99%可能被恶意访问空对象
             * 因为创建短链接时，完整短链接url是会插入到布隆过滤器里面的
             * 而除了数据库和布隆过滤器数据不一致的情况外，它是不会将一个存在的数据判定为空的
             */
            this.redirect(response, "/page/notfound");
            return;
        }
        // 4.2 缓存穿透-缓存空值
        String shortLinkIsNullKey = SHORT_LINK_GOTO_IS_NULL_KEY + fullShortUrl;
        String isNullValue = stringRedisTemplate.opsForValue().get(shortLinkIsNullKey);
        if (SHORT_LINK_GOTO_IS_NULL_VALUE.equals(isNullValue)) {
            /**
             * 缓存中存在空值，说明数据库中不存在该短链接
             */
            this.redirect(response, "/page/notfound");
            return;
        }
        // 5. 缓存击穿
        String shortLinkLockKey = LOCK_SHORT_LINK_GOTO_ORIGIN_KEY + fullShortUrl;
        RLock lock = redissonClient.getLock(shortLinkLockKey);
        lock.lock();
        try {
            // double check
            originUrl = stringRedisTemplate.opsForValue().get(shortLinkCacheKey);
            if (StringUtils.isNotEmpty(originUrl)) {
                // 监控短链接
                setCookieAndMonitorShortLink(fullShortUrl, null, request, response);
                this.redirect(response, originUrl);
                return;
            }
            // 5.1 找出对应的gid
            LambdaQueryWrapper<LinkGotoDO> linkGotoQueryWrapper = Wrappers.lambdaQuery(LinkGotoDO.class)
                    .eq(LinkGotoDO::getFullShortUrl, fullShortUrl);
            LinkGotoDO linkGotoDO = linkGotoMapper.selectOne(linkGotoQueryWrapper);
            if (linkGotoDO == null) {
                throw new ClientException("短链接url错误");
            }
            // 5.2 根据userId, gid, full_short_url 找到 原始链接 origin_url
            Long userId = linkGotoDO.getUserId();
            String gid = linkGotoDO.getGid();
            LambdaQueryWrapper<LinkDO> queryWrapper = Wrappers.lambdaQuery(LinkDO.class)
                    .eq(LinkDO::getUserId, userId)
                    .eq(LinkDO::getGid, gid)
                    .eq(LinkDO::getFullShortUrl, fullShortUrl)
                    .eq(LinkDO::getEnableStatus, 0);
            LinkDO linkDO = baseMapper.selectOne(queryWrapper);
            if (linkDO == null || (linkDO.getValidDate() != null && linkDO.getValidDate().before(new Date()))) {
                // 缓存穿透插入空值
                stringRedisTemplate.opsForValue().set(shortLinkIsNullKey, SHORT_LINK_GOTO_IS_NULL_VALUE, 30, TimeUnit.SECONDS);
                this.redirect(response, "/page/notfound");
                return;
            }
            // 写入缓存
            stringRedisTemplate.opsForValue().set(shortLinkCacheKey, linkDO.getOriginUrl(),
                    LinkUtil.getCacheShortLinkValidTime(linkDO.getValidDate()), TimeUnit.SECONDS);
            // 监控短链接
            setCookieAndMonitorShortLink(fullShortUrl, userId, request, response);
            // 跳转原始链接
            this.redirect(response, linkDO.getOriginUrl());
        } finally {
            lock.unlock();
        }
    }

    @Override
    public LinkBatchCreateRespDTO batchCreateShortLink(LinkBatchCreateReqDTO requestParam) {
        List<String> originUrls = requestParam.getOriginUrls();
        List<String> describes = requestParam.getDescribes();
        String gid = requestParam.getGid();
        Long userId = requestParam.getUserId();
        if (CollectionUtil.isEmpty(originUrls)) {
            throw new ClientException("短链不能为空");
        }
        if (StringUtils.isEmpty(gid)) {
            throw new ClientException("分组不能为空");
        }
        if (userId == null) {
            throw new ClientException("创建人id不能为空");
        }
        List<LinkBaseInfoRespDTO> result = new ArrayList<>();
        for (int i = 0; i < originUrls.size(); i++) {
            LinkCreateReqDTO linkCreateReqDTO = BeanUtil.toBean(requestParam, LinkCreateReqDTO.class);
            linkCreateReqDTO.setOriginUrl(originUrls.get(i));
            linkCreateReqDTO.setDescribe(describes.get(i));
            try {
                LinkCreateRespDTO shortLink = createShortLink(linkCreateReqDTO);
                LinkBaseInfoRespDTO linkBaseInfoRespDTO = LinkBaseInfoRespDTO.builder()
                        .fullShortUrl(shortLink.getFullShortUrl())
                        .originUrl(shortLink.getOriginUrl())
                        .describe(describes.get(i))
                        .build();
                result.add(linkBaseInfoRespDTO);
            } catch (Throwable ex) {
                log.error("批量创建短链接失败，原始参数：{}", originUrls.get(i));
            }
        }
        return LinkBatchCreateRespDTO.builder()
                .baseLinkInfos(result)
                .total(result.size())
                .build();
    }

    /**
     * 给 cookie设置uv字段，将监控基础信息发送至mq削峰
     * TODO 这里可以做的是用条配置动态更换mq的类型
     * @param fullShortUrl
     * @param userId
     * @param request
     * @param response
     */
    private void setCookieAndMonitorShortLink(String fullShortUrl, Long userId, HttpServletRequest request, HttpServletResponse response) {
        LinkStatsRecordDTO statsRecord = buildLinkStatsRecordAndSetUser(fullShortUrl, userId, request, response);
        linkStatsProducer.sendOneWayMessage(
                RocketMqConstant.LINK_STATS_TOPIC,
                RocketMqConstant.LINK_STATS_SAVE_TAG,
                statsRecord
        );
    }


    /**
     * 给cookie 中添加uv字段，key = uv value = 随机数（用户唯一标识）
     *
     * @param fullShortUrl
     * @param request
     * @param response
     * @return tuple2 v1 是uv的值 v2是 判断是否是第一个uv
     */
    private LinkStatsRecordDTO buildLinkStatsRecordAndSetUser(String fullShortUrl, Long userId, HttpServletRequest request, HttpServletResponse response) {
        Cookie[] cookies = request.getCookies();
        String uvValue = null;
        boolean isFirstUV = false;

        // 获取现有的UV cookie值
        if (ArrayUtil.isNotEmpty(cookies)) {
            Optional<Cookie> uvCookieOptional = Arrays.stream(cookies)
                    .filter(cookie -> Objects.equals(cookie.getName(), "uv"))
                    .findFirst();
            if (uvCookieOptional.isPresent()) {
                uvValue = uvCookieOptional.get().getValue();
            }
        }

        // 如果UV cookie不存在，则创建新的UV，并标记为第一次UV访问
        if (uvValue == null) {
            uvValue = IdUtil.simpleUUID();
            Cookie uvCookie = new Cookie("uv", uvValue);
            uvCookie.setMaxAge(60 * 60 * 24 * 30);
            uvCookie.setPath(StrUtil.sub(fullShortUrl, fullShortUrl.indexOf("/"), fullShortUrl.length()));
            response.addCookie(uvCookie);
            isFirstUV = true;
            // 将uv加入到redis中
            stringRedisTemplate.opsForHyperLogLog().add(STATS_UV_KEY + fullShortUrl, uvValue + fullShortUrl);
        } else {
            // 如果有uv值了，就判断该条是否是该条短链的uv值
            Long uvAdded = stringRedisTemplate.opsForHyperLogLog().add(STATS_UV_KEY + fullShortUrl, uvValue + fullShortUrl);
            if (uvAdded > 0L) {
                isFirstUV = true;
            }
        }
        String realIp = LinkUtil.getRealIp(request);
        String os = LinkUtil.getOs(request);
        String browser = LinkUtil.getBrowser(request);
        String device = LinkUtil.getDevice(request);
        String network = LinkUtil.getNetwork(request);
        Long uipAdded = stringRedisTemplate.opsForHyperLogLog().add(STATS_UIP_KEY + fullShortUrl, realIp);
        boolean isFirstUip = uipAdded > 0L;
        return LinkStatsRecordDTO.builder()
                .fullShortUrl(fullShortUrl)
                .userId(userId)
                .uvValue(uvValue)
                .isFirstUv(isFirstUV)
                .isFirstUip(isFirstUip)
                .ip(realIp)
                .os(os)
                .browser(browser)
                .device(device)
                .network(network)
                .build();
    }

    /**
     * 封装了HttpServletResponse的重定向方法
     *
     * @param response
     * @param url
     */
    private void redirect(HttpServletResponse response, String url) {
        try {
            response.sendRedirect(url);
        } catch (IOException e) {
            log.error("短链接跳转：响应设置重定向错误 {}", e);
            throw new ServiceException("短链接跳转失败");
        }
    }

    /**
     * 根据提供的目标url生成短链接uri
     *
     * @param domain    判断该域名下的短链uri是否重复
     * @param originUrl
     * @return
     */
    private String generateShortLinkSuffix(String originUrl, String domain) {
        /**
         * 这里的布隆过滤器会出现误判的情况
         * 1. 可以用的短链url，误判存在 --> 重试3次
         * 2. 因为数据库与布隆过滤器数据不一致导致存在的短链url，误判不存在 --> 数据库唯一索引抛异常
         */
        int maxRetrySize = 3;
        int count = 0;
        while (count++ < maxRetrySize) {
            String shortLinkSuffix = HashUtil.hashToBase62(originUrl);
            String fullShortUrl = domain + "/" + shortLinkSuffix;
            if (!shortLinkCreateCachePenetrationBloomFilter.contains(fullShortUrl)) {
                return shortLinkSuffix;
            }
            originUrl += IdUtil.simpleUUID();
            log.warn("短链接uri：uri生成冲突，重试次数: {}", count);
        }
        throw new ServiceException("创建短链接失败，请稍后重试");
    }

    /**
     * 判断该域名是否在黑白名单内
     *
     * @param originUrl 目标域名
     */
    private void verificationBlackWhitelist(String originUrl) {
        Boolean enable = gotoDomainBlackListProperties.getEnable();
        if (!enable) {
            return;
        }
        if (StringUtils.isEmpty(originUrl)) {
            return ;
        }
        String domain = LinkUtil.extractDomain(originUrl);
        if (StrUtil.isBlank(domain)) {
            throw new ClientException("跳转链接填写错误");
        }
        List<String> domains = gotoDomainBlackListProperties.getDomains();
        if (domains.contains(domain)) {
            throw new ClientException(gotoDomainBlackListProperties.getDescription());
        }
    }
}




